1use std::io::ErrorKind;
2use std::path::Path;
3
4use crate::core::hash::hash_file;
5use crate::core::{HashError, verify_hash};
6use crate::models::domains::installed::InstalledPackage;
7use crate::models::domains::inventory::MsiFileRecord;
8use crate::models::domains::reporting::{DiagnosisResult, DiagnosisSeverity};
9
10use super::{ScanResult, sort_diagnoses, sort_recovery_findings};
11
12pub(crate) type MsiInventoryScan = ScanResult;
13
14pub(super) fn diagnose_msi_file(
16 pkg: &InstalledPackage,
17 file: &MsiFileRecord,
18) -> Option<DiagnosisResult> {
19 let path = Path::new(&file.path);
20
21 let metadata = match std::fs::metadata(path) {
22 Ok(metadata) => metadata,
23 Err(err) => {
24 return Some(diagnose_msi_file_error(pkg, file, err));
25 }
26 };
27
28 if !metadata.is_file() {
29 return Some(DiagnosisResult {
30 error_code: "msi_file_not_a_file".to_string(),
31 description: format!("{}: MSI file path is not a file ({})", pkg.name, file.path),
32 severity: DiagnosisSeverity::Error,
33 });
34 }
35
36 let (Some(hash_algorithm), Some(expected_hash)) =
37 (file.hash_algorithm, file.hash_hex.as_deref())
38 else {
39 return None;
40 };
41
42 let actual_hash = match hash_file(path, hash_algorithm) {
43 Ok(actual_hash) => actual_hash,
44 Err(err) => {
45 return Some(DiagnosisResult {
46 error_code: "msi_file_unreadable".to_string(),
47 description: format!(
48 "{}: MSI file is unreadable for hashing ({}) - {}",
49 pkg.name, file.path, err
50 ),
51 severity: DiagnosisSeverity::Error,
52 });
53 }
54 };
55
56 match verify_hash(expected_hash, actual_hash) {
57 Ok(()) => None,
58 Err(HashError::ChecksumMismatch { expected, actual }) => Some(DiagnosisResult {
59 error_code: "msi_file_hash_mismatch".to_string(),
60 description: format!(
61 "{}: MSI file hash mismatch for {} (expected {}, got {})",
62 pkg.name, file.path, expected, actual
63 ),
64 severity: DiagnosisSeverity::Error,
65 }),
66 Err(err) => Some(DiagnosisResult {
67 error_code: "msi_file_hash_unavailable".to_string(),
68 description: format!(
69 "{}: MSI file hash could not be verified for {} - {}",
70 pkg.name, file.path, err
71 ),
72 severity: DiagnosisSeverity::Error,
73 }),
74 }
75}
76
77fn diagnose_msi_file_error(
79 pkg: &InstalledPackage,
80 file: &MsiFileRecord,
81 err: std::io::Error,
82) -> DiagnosisResult {
83 let (error_code, description) = match err.kind() {
84 ErrorKind::NotFound => (
85 "missing_msi_file",
86 format!("{}: missing MSI file ({})", pkg.name, file.path),
87 ),
88 ErrorKind::PermissionDenied => (
89 "msi_file_permission_denied",
90 format!("{}: MSI file permission denied ({})", pkg.name, file.path),
91 ),
92 _ => (
93 "msi_file_unreadable",
94 format!(
95 "{}: MSI file is unreadable ({}) - {}",
96 pkg.name, file.path, err
97 ),
98 ),
99 };
100
101 DiagnosisResult {
102 error_code: error_code.to_string(),
103 description,
104 severity: DiagnosisSeverity::Error,
105 }
106}
107
108pub(crate) fn scan_msi_inventory(
109 conn: &crate::database::DbConnection,
110 packages: &[InstalledPackage],
111) -> MsiInventoryScan {
112 let mut scan: MsiInventoryScan = Default::default();
113
114 for pkg in packages.iter().filter(|pkg| {
115 matches!(
116 pkg.engine_kind,
117 crate::models::domains::install::EngineKind::Msi
118 )
119 }) {
120 let snapshot = match crate::database::get_snapshot(conn, &pkg.name) {
121 Ok(Some(snapshot)) => snapshot,
122 Ok(None) => {
123 scan.push(
124 DiagnosisResult {
125 error_code: "missing_msi_inventory_snapshot".to_string(),
126 description: format!("{}: MSI inventory snapshot is missing", pkg.name),
127 severity: DiagnosisSeverity::Error,
128 },
129 None,
130 );
131 continue;
132 }
133 Err(err) => {
134 scan.push(
135 DiagnosisResult {
136 error_code: "msi_inventory_unreadable".to_string(),
137 description: format!("{}: MSI inventory is unreadable - {err}", pkg.name),
138 severity: DiagnosisSeverity::Error,
139 },
140 None,
141 );
142 continue;
143 }
144 };
145
146 for file in &snapshot.files {
147 if let Some(diagnosis) = diagnose_msi_file(pkg, file) {
148 scan.push(diagnosis, Some(Path::new(&file.path)));
149 }
150 }
151 }
152
153 sort_diagnoses(&mut scan.diagnostics);
154 sort_recovery_findings(&mut scan.recovery_findings);
155
156 scan
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::core::paths::{ResolvedPaths, resolved_paths};
163 use crate::database;
164 use crate::models::domains::install::EngineKind;
165 use crate::models::domains::install::InstallerType;
166 use crate::models::domains::installed::PackageStatus;
167 use crate::models::domains::inventory::{
168 MsiComponentRecord, MsiInventoryReceipt, MsiInventorySnapshot, MsiRegistryRecord,
169 MsiShortcutRecord,
170 };
171 use crate::models::domains::reporting::{RecoveryActionGroup, RecoveryIssueKind};
172 use crate::models::domains::shared::HashAlgorithm;
173 use std::fs;
174 use std::path::PathBuf;
175 use tempfile::{TempDir, tempdir};
176
177 fn sample_file_record(name: &str, path: &Path, hash_hex: &str) -> MsiFileRecord {
178 let normalized_path = path
179 .to_string_lossy()
180 .replace('\\', "/")
181 .to_ascii_lowercase();
182
183 MsiFileRecord {
184 package_name: name.to_string(),
185 path: path.to_string_lossy().into_owned(),
186 normalized_path,
187 hash_algorithm: Some(HashAlgorithm::Sha256),
188 hash_hex: Some(hash_hex.to_string()),
189 is_config_file: false,
190 }
191 }
192
193 fn sample_snapshot(
194 name: &str,
195 install_dir: &std::path::Path,
196 hash_hex: &str,
197 ) -> MsiInventorySnapshot {
198 let install_dir = install_dir
199 .to_string_lossy()
200 .replace('\\', "/")
201 .to_ascii_lowercase();
202 let file_path = format!("{install_dir}/bin/demo.exe");
203
204 MsiInventorySnapshot {
205 receipt: MsiInventoryReceipt {
206 package_name: name.to_string(),
207 product_code: "{11111111-1111-1111-1111-111111111111}".to_string(),
208 upgrade_code: Some("{22222222-2222-2222-2222-222222222222}".to_string()),
209 scope: winbrew_models::domains::install::InstallScope::Installed,
210 },
211 files: vec![sample_file_record(name, Path::new(&file_path), hash_hex)],
212 registry_entries: vec![MsiRegistryRecord {
213 package_name: name.to_string(),
214 hive: "HKLM".to_string(),
215 key_path: "Software\\Demo".to_string(),
216 normalized_key_path: "software\\demo".to_string(),
217 value_name: "InstallPath".to_string(),
218 value_data: Some(install_dir.clone()),
219 previous_value: None,
220 }],
221 shortcuts: vec![MsiShortcutRecord {
222 package_name: name.to_string(),
223 path: format!("{install_dir}/Desktop/Demo.lnk"),
224 normalized_path: format!("{install_dir}/desktop/demo.lnk"),
225 target_path: Some(format!("{install_dir}/bin/demo.exe")),
226 normalized_target_path: Some(format!("{install_dir}/bin/demo.exe")),
227 }],
228 components: vec![MsiComponentRecord {
229 package_name: name.to_string(),
230 component_id: "COMPONENT-DEMO".to_string(),
231 path: Some(format!("{install_dir}/bin/demo.exe")),
232 normalized_path: Some(format!("{install_dir}/bin/demo.exe")),
233 }],
234 }
235 }
236
237 struct TestEnvironment {
238 _root: TempDir,
239 paths: ResolvedPaths,
240 }
241
242 impl TestEnvironment {
243 fn new() -> Self {
244 let root = tempdir().expect("temp dir should be created");
245 let paths = Self::build_paths(root.path());
246
247 Self { _root: root, paths }
248 }
249
250 fn with_storage() -> Self {
251 let env = Self::new();
252 database::init(&env.paths).expect("database should initialize");
253 env
254 }
255
256 fn build_paths(root: &Path) -> ResolvedPaths {
257 let packages = root.join("packages").to_string_lossy().into_owned();
258 let data = root.join("data").to_string_lossy().into_owned();
259 let logs = root.join("logs").to_string_lossy().into_owned();
260 let cache = root.join("cache").to_string_lossy().into_owned();
261
262 resolved_paths(root, &packages, &data, &logs, &cache)
263 }
264
265 fn packages_root(&self) -> &Path {
266 &self.paths.packages
267 }
268
269 fn create_dir(&self, path: &Path) {
270 fs::create_dir_all(path).expect("directory should be created");
271 }
272
273 fn write_file(&self, path: &Path, content: &[u8]) {
274 if let Some(parent) = path.parent() {
275 fs::create_dir_all(parent).expect("parent directory should be created");
276 }
277
278 fs::write(path, content).expect("file should be written");
279 }
280
281 fn db_conn(&self) -> database::DbConnection {
282 database::get_conn().expect("database connection")
283 }
284
285 fn insert_package(&self, package: &InstalledPackage) -> database::DbConnection {
286 let conn = self.db_conn();
287 database::insert_package(&conn, package).expect("insert package");
288 conn
289 }
290
291 fn make_msi_package(&self, name: &str) -> (InstalledPackage, PathBuf) {
292 let install_dir = self.packages_root().join(name);
293 (sample_package(name, &install_dir), install_dir)
294 }
295 }
296
297 fn assert_normalized_recovery_target_path(
298 finding: &crate::models::domains::reporting::RecoveryFinding,
299 expected_path: &Path,
300 ) {
301 let expected_path = expected_path
302 .to_string_lossy()
303 .replace('\\', "/")
304 .to_ascii_lowercase();
305
306 assert_eq!(finding.target_path.as_deref(), Some(expected_path.as_str()));
307 }
308
309 fn sample_package(name: &str, install_dir: &std::path::Path) -> InstalledPackage {
310 InstalledPackage {
311 name: name.to_string(),
312 version: "1.0.0".to_string(),
313 kind: InstallerType::Msi,
314 deployment_kind: InstallerType::Msi.deployment_kind(),
315 engine_kind: EngineKind::Msi,
316 engine_metadata: None,
317 install_dir: install_dir.to_string_lossy().into_owned(),
318 dependencies: Vec::new(),
319 status: PackageStatus::Ok,
320 installed_at: "2026-04-05T00:00:00Z".to_string(),
321 }
322 }
323
324 #[test]
325 fn diagnose_msi_file_error_maps_not_found_to_missing_msi_file() {
326 let temp_dir = tempdir().expect("temp dir should be created");
327 let package = sample_package("Contoso.Msi", temp_dir.path());
328 let file_path = temp_dir.path().join("missing.exe");
329 let hash_hex = "00".repeat(32);
330 let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
331
332 let diagnosis = diagnose_msi_file_error(
333 &package,
334 &file,
335 std::io::Error::from(std::io::ErrorKind::NotFound),
336 );
337
338 assert_eq!(diagnosis.error_code, "missing_msi_file");
339 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
340 assert!(diagnosis.description.contains("Contoso.Msi"));
341 }
342
343 #[test]
344 fn diagnose_msi_file_error_maps_permission_denied() {
345 let temp_dir = tempdir().expect("temp dir should be created");
346 let package = sample_package("Contoso.Msi", temp_dir.path());
347 let file_path = temp_dir.path().join("missing.exe");
348 let hash_hex = "00".repeat(32);
349 let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
350
351 let diagnosis = diagnose_msi_file_error(
352 &package,
353 &file,
354 std::io::Error::from(std::io::ErrorKind::PermissionDenied),
355 );
356
357 assert_eq!(diagnosis.error_code, "msi_file_permission_denied");
358 assert_eq!(diagnosis.severity, DiagnosisSeverity::Error);
359 assert!(diagnosis.description.contains("Contoso.Msi"));
360 }
361
362 #[test]
363 fn scan_msi_inventory_attaches_file_restore_targets() {
364 let temp_dir = tempdir().expect("temp dir should be created");
365 let package = sample_package("Contoso.Msi", temp_dir.path());
366 let file_path = temp_dir.path().join("missing.exe");
367 let hash_hex = "00".repeat(32);
368 let file = sample_file_record("Contoso.Msi", &file_path, &hash_hex);
369
370 let diagnosis = diagnose_msi_file_error(
371 &package,
372 &file,
373 std::io::Error::from(std::io::ErrorKind::NotFound),
374 );
375 let mut scan: MsiInventoryScan = Default::default();
376 scan.push(diagnosis, Some(Path::new(&file.path)));
377
378 assert_eq!(scan.recovery_findings.len(), 1);
379 assert_eq!(
380 scan.recovery_findings[0].action_group,
381 Some(RecoveryActionGroup::FileRestore)
382 );
383 assert_eq!(
384 scan.recovery_findings[0].issue_kind,
385 RecoveryIssueKind::DiskDrift
386 );
387 assert_eq!(
388 scan.recovery_findings[0].target_path.as_deref(),
389 Some(file.path.as_str())
390 );
391 }
392
393 #[test]
394 fn scan_msi_inventory_detects_hash_mismatch() {
395 let env = TestEnvironment::with_storage();
396
397 let (package, install_dir) = env.make_msi_package("Contoso.Msi");
398 let file_path = install_dir.join("bin").join("demo.exe");
399 env.create_dir(file_path.parent().expect("file parent"));
400 env.write_file(&file_path, b"abc");
401
402 let snapshot = sample_snapshot(
403 "Contoso.Msi",
404 &install_dir,
405 "0000000000000000000000000000000000000000000000000000000000000000",
406 );
407
408 let mut conn = env.insert_package(&package);
409 database::replace_snapshot(&mut conn, &snapshot).expect("replace snapshot");
410
411 let scan = scan_msi_inventory(&conn, &[package]);
412
413 assert_eq!(scan.diagnostics.len(), 1);
414 assert_eq!(scan.diagnostics[0].error_code, "msi_file_hash_mismatch");
415 assert_eq!(scan.diagnostics[0].severity, DiagnosisSeverity::Error);
416 assert!(scan.diagnostics[0].description.contains("Contoso.Msi"));
417
418 assert_eq!(scan.recovery_findings.len(), 1);
419 assert_eq!(
420 scan.recovery_findings[0].issue_kind,
421 RecoveryIssueKind::DiskDrift
422 );
423 assert_eq!(
424 scan.recovery_findings[0].action_group,
425 Some(RecoveryActionGroup::FileRestore)
426 );
427 assert_normalized_recovery_target_path(&scan.recovery_findings[0], &file_path);
428 }
429
430 #[test]
431 fn scan_msi_inventory_detects_missing_files() {
432 let env = TestEnvironment::with_storage();
433
434 let (package, install_dir) = env.make_msi_package("Contoso.Msi");
435 env.create_dir(&install_dir);
436
437 let snapshot = sample_snapshot(
438 "Contoso.Msi",
439 &install_dir,
440 "0000000000000000000000000000000000000000000000000000000000000000",
441 );
442
443 let mut conn = env.insert_package(&package);
444 database::replace_snapshot(&mut conn, &snapshot).expect("replace snapshot");
445
446 let scan = scan_msi_inventory(&conn, &[package]);
447
448 assert_eq!(scan.diagnostics.len(), 1);
449 assert_eq!(scan.diagnostics[0].error_code, "missing_msi_file");
450 assert_eq!(scan.diagnostics[0].severity, DiagnosisSeverity::Error);
451 assert!(scan.diagnostics[0].description.contains("Contoso.Msi"));
452
453 assert_eq!(scan.recovery_findings.len(), 1);
454 assert_eq!(
455 scan.recovery_findings[0].issue_kind,
456 RecoveryIssueKind::DiskDrift
457 );
458 assert_eq!(
459 scan.recovery_findings[0].action_group,
460 Some(RecoveryActionGroup::FileRestore)
461 );
462 assert_normalized_recovery_target_path(
463 &scan.recovery_findings[0],
464 &install_dir.join("bin").join("demo.exe"),
465 );
466 }
467}